昨天講的是「AI 會給你猜測」。今天講一個更難防的狀況:AI 給的建議完全正確,但它沒告訴你,這個正確在真實資料量下會爆炸。
這篇的主角是 Promise.all——Node.js 裡最常被推薦、也最常被誤用的一個工具。
情境很日常:我要先撈出主表的一批鍵值,再拿這些鍵值去三張明細表把資料補齊。
我是 Java 背景出身,直覺就是巢狀迴圈——外層跑主表,內層對每一筆去查三張明細表。但我知道 Node.js 的非同步生態跟 Java 不一樣,所以我問了 AI:「Node.js 有沒有建議的做法?」
它的回答乾脆俐落:別用巢狀迴圈,那是序列等待、很慢。用 Promise.all 並行處理。
// AI 給的第一個版本
const results = await Promise.all(
mainIds.map(async (id) => {
const [detail1, detail2, detail3] = await Promise.all([
queryDetail1(id),
queryDetail2(id),
queryDetail3(id),
]);
return { id, detail1, detail2, detail3 };
})
);
這段程式碼看起來很漂亮,很「Node.js」。並行、非同步、不阻塞——所有你聽過的好詞它都占了。如果我是趕時間、看它跑得動就收工的那種心態,這段就直接進專案了。
而且平心而論,這個建議在教科書意義上是對的。巢狀迴圈的序列等待確實慢,Promise.all 確實能並行。它沒有騙我。
我沒有馬上採用,因為我腦子裡浮現了我們系統的一個真實設定:主表查詢預設最多撈 200 筆。
於是我盯著那段程式碼,開始在腦中把它「攤開」執行一次:
mainIds.map:200 個 id,同時建立 200 個 async functionPromise.all:同時發 3 個查詢我把這個疑慮丟回給 AI:「200 個 key、3 張明細表,就是 600 個查詢同時跑,這不是效能炸彈嗎?」
它這時候才承認:對,這就是效能炸彈。
Promise.all 不會幫你踩剎車這裡有一個很多人沒意識到的真相:Promise.all 不做任何併發控制。
它的職責只有一句話:把你給它的所有 Promise 全部同時發出去,然後等它們全部回來。它不會排隊、不會限流、不會看你的資料庫連線池有幾條連線。你丟 600 個查詢給它,它就真的讓 600 個查詢在同一瞬間出發。
而資料庫的連線池通常只有幾十條連線(預設常見是 10 到 50)。600 個查詢同時搶幾十條連線的下場是:大量查詢排隊等連線、連線池耗盡、然後開始拋 timeout 或連線耗盡的錯誤。你的「效能優化」反而讓整個服務癱瘓。
更陰險的是——這個 bug 在開發時測不出來。 你本機測十筆、五十筆,一切順暢飛快。等上了正式環境、真的來了 200 筆,才在尖峰時段炸給你看。
追問到這裡,我跟 AI 一起把三種做法攤成一張表對照——這張表是整件事的精華:
| 做法 | 查詢次數 | 網路往返 | 結果 |
|---|---|---|---|
| 巢狀迴圈(序列) | 3N+1 | 3N+1 次,排隊等 | 慢,但至少不炸 |
Promise.all 逐筆並行 |
3N+1 | 3N+1 次,同時發 | 炸連線池 |
| 批次 IN 查詢 | 3+1 = 4 | 4 次 | 最快,也最穩 |
關鍵的體悟是:Promise.all 只是把「排隊等」變成「同時等」,它從頭到尾沒有減少查詢的『次數』。 巢狀迴圈打 601 次、Promise.all 也是打 601 次,只是後者一次全送出去而已。它優化的是等待時間,代價卻是瞬間把資料庫打爆。
真正該做的,是從源頭把查詢次數壓下來——用批次 IN 查詢,一次撈一整批:
// 每張明細表只查一次,用 IN 一次撈回所有 id 的資料
const [details1, details2, details3] = await Promise.all([
queryDetail1Batch(mainIds), // WHERE id IN (200 個 key)
queryDetail2Batch(mainIds),
queryDetail3Batch(mainIds),
]);
// 再用 Map 在記憶體裡組裝,O(1) 查找
const map1 = new Map(details1.map(d => [d.id, d]));
// ...
601 次查詢,變成 3 次。而這裡的 Promise.all 只包著 3 個查詢——它終於用對了:並行少數幾個批次查詢,而不是並行上百個逐筆查詢。
(一個真實世界的但書:IN 查詢要跑得快,那個欄位得有索引。我們的系統是很久以前規劃的,當年沒有開主鍵外鍵的習慣,頂多開唯一索引——所以我還特地確認了那個欄位有唯一索引,IN (200) 才真的沒問題。這又回到 Day 02 的老話:不能假設它「應該」有索引,要自己去確認。)
寫到這裡,熟 Node.js 的人可能會問:既然問題是「600 個查詢同時發」,那用 p-limit 之類的套件把併發數限制成一次只跑 10 個,不就解決了嗎?
會這樣問是對的,而且 p-limit 確實是處理大量並發的標準工具。但在這個案例裡,它是第二選擇,原因值得說清楚:
p-limit 解決的是症狀,批次 IN 解決的是病根。
用 p-limit 把併發限制成 10,連線池確實不會炸了——但你還是打了 600 次查詢給資料庫,只是從「600 個同時擠」變成「600 個排隊慢慢打」。查詢次數一次都沒少,你只是把「爆炸」換成了「緩慢」。而批次 IN 是直接讓查詢次數從 601 降到 4。當一個問題可以從源頭消除,限流就只是在幫一個本來就不該存在的負擔排隊。
所以這兩個工具的定位其實不同:
| 工具 | 它做的事 | 適用時機 |
|---|---|---|
| 批次 IN | 減少查詢次數(601 → 4) | 查詢是同構的、可以合併(例如都是查同一張表的不同 id) |
p-limit |
控制查詢的併發數(同時最多 N 個) | 查詢無法合併時的退路 |
p-limit 不是更差的選項,是不同場景的選項。如果我要處理的不是「查同一張明細表的 200 個 id」,而是「對 200 個項目各自呼叫一個外部 API」或「每筆要跑不一樣的複雜邏輯」——這種無法用一句 IN 合併的情況,查詢次數壓不下來,那 p-limit 就是對的工具,該用就用。
判斷的順序是:先問「這些查詢能不能合併成一次」(能就批次),不能,才問「那我怎麼控制它同時跑幾個」(用 p-limit)。 先治病根,治不了才處理症狀。
回頭看,AI 給的第一個 Promise.all 版本沒有錯,它只是不完整——它給了一個在小資料量下正確、在真實資料量下危險的答案,卻沒有主動標示那條界線在哪。
這帶出一條我後來固定會做的檢查:拿到任何一個『並行』『批次』『一次處理全部』的建議時,先問一個問題——如果資料量是現在的 10 倍、100 倍,這段程式會怎樣?
Promise.all(array.map(...)) 這個模式尤其要警覺。它讀起來人畜無害,但只要那個 array 的長度是會隨資料成長的、裡面每一項又各自發查詢,它就是一顆定時炸彈。判斷「這個並行安不安全」,關鍵從來不是語法對不對,而是那個陣列的長度上限是多少、以及它會不會隨業務長大。
AI 很擅長給你「怎麼做」——那是它的強項。但「在你的規模下這樣做會怎樣」,這個問題往往要你自己提出來。它不會主動幫你把最壞情況演一遍,因為它不知道你系統的量級、成長曲線、連線池有幾條。這些脈絡是人握著的,把它們帶進判斷、替 AI 的建議畫出安全邊界——這正是人在這段協作裡貢獻的那一份,也是讓「並行優化」真的變成優化、而不是變成事故的關鍵。
明天換一個更根本的問題:當你只是想解決一件小事,AI 卻熱情地幫你把它做成一整套系統時,該怎麼辦。一個關於「先設邊界」的故事——我只是想取一份帳號資料,最後差點蓋出一套帳號生命週期管理系統。